Redis를 캐시로 사용할 때 원본 데이터와의 관계
Redis를 캐시로 사용할 때 원본 데이터와의 관계
Cache Aside에서 DB는 authoritative source이고 Redis 값은 언제든 사라져도 다시 만들 수 있는 파생 데이터다. TTL 만료뿐 아니라 maxmemory eviction, 재시작과 장애로 key가 사라질 수 있으므로 캐시 hit를 전제로 비즈니스 정합성을 만들면 안 된다. 캐시가 없을 때의 DB 보호, 직렬화 버전, negative cache와 warm-up까지 실패 경로를 먼저 설계해야 한다.
목차
- #문제가 되는 상황
- #원본과 캐시의 책임을 먼저 선언한다
- #Cache Aside 읽기 흐름
- #캐시 데이터는 TTL 전에도 사라질 수 있다
- #Redis persistence와 복제가 원본을 대신하지는 않는다
- #TTL은 정합성 모델의 일부다
- #존재하지 않음도 짧게 캐시한다
- #직렬화 포맷에도 버전이 필요하다
- #캐시 키는 데이터 계약이다
- #Redis 장애 시 DB를 어떻게 보호할까
- #Cache Stampede와 동시 miss
- #Cold Start와 캐시 워밍
- #캐시하면 안 되는 데이터
- #운영 지표와 장애 테스트
- #결론
- #관련 노트
- #참고 자료
문제가 되는 상황
상품 상세 조회를 Redis로 캐시한다고 하자.
async function getProduct(productId: number): Promise<Product> {
const key = `product:${productId}`;
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
const product = await productRepository.findById(productId);
await redis.set(key, JSON.stringify(product), { EX: 300 });
return product;
}
처음에는 간단히 빨라진다. 그런데 어느 날 Redis의 메모리 한도에 도달해 key가 eviction되고, 인기 상품 요청이 동시에 DB로 몰린다. 또는 Redis가 장애라 모든 요청이 timeout까지 기다린 뒤 DB를 조회한다. 최악에는 애플리케이션이 상품 재고를 Redis에서만 차감해 왔는데 key가 사라져 현재 재고를 복구할 기준도 없다.
캐시를 도입할 때 가장 먼저 정해야 하는 것은 TTL 숫자가 아니라 다음 질문이다.
Redis 전체가 지금 비어 있어도 시스템이 원본 데이터에서 올바른 상태를 복구할 수 있는가?
대답이 “아니오”라면 그 Redis 데이터는 단순 캐시가 아니며 durability와 복구를 별도로 설계해야 한다.
원본과 캐시의 책임을 먼저 선언한다
Cache Aside의 기본 역할은 다음과 같다.
Database
= source of truth
= 트랜잭션과 제약으로 정합성을 보장
= 장애 후 복구 기준
Redis Cache
= DB에서 계산 가능한 파생 데이터
= 지연 시간과 DB 부하를 줄임
= TTL·eviction·flush로 손실 가능
flowchart LR
A[Client] --> B[Application]
B --> C[(Redis Cache)]
B --> D[(Primary Database)]
D -. rebuild .-> C이 관계가 명확하면 Redis key가 없어졌을 때 행동도 단순하다. miss로 처리하고 DB에서 최신 값을 읽어 다시 채운다.
반면 다음처럼 DB와 Redis 양쪽에서 일부 값을 독립적으로 바꾸면 source of truth가 둘이 된다.
DB product.stock = 10
Redis product:42:stock = 8
어느 값이 진짜인가?
DB는 주문 트랜잭션에서 바뀌고 Redis는 별도 요청에서 차감된다면 네트워크 실패와 재시도 때 쉽게 어긋난다. Redis를 원본 저장소나 원자적 counter로 사용하는 아키텍처도 가능하지만, 그 경우에는 “캐시”라고 부르며 손실을 허용해서는 안 된다. persistence, backup, failover, consistency와 reconciliation을 원본 저장소 수준으로 설계해야 한다.
product detail → DB, computed recommendation → batch output, rate limit counter → Redis처럼 데이터마다 권위 있는 저장소와 손실 허용 범위를 적는다.
Cache Aside 읽기 흐름
Cache Aside에서는 애플리케이션이 캐시와 DB를 직접 조정한다.
sequenceDiagram
participant C as Client
participant A as Application
participant R as Redis
participant D as Database
C->>A: GET /products/42
A->>R: GET cache:product:v3:42
alt cache hit
R-->>A: cached value
A-->>C: response
else cache miss
R-->>A: nil
A->>D: SELECT product
D-->>A: source value
A->>R: SET value EX 300
A-->>C: response
end구현에서는 Redis 오류와 miss를 구분하되, 둘 다 DB fallback이 가능한지 정책을 둔다.
type CachedProduct = {
schemaVersion: 3;
value: ProductView;
cachedAt: string;
};
async function getProduct(productId: number): Promise<ProductView | null> {
const key = productCacheKey(productId);
try {
const raw = await redis.get(key);
const decoded = raw ? decodeCachedProduct(raw) : null;
if (decoded) {
metrics.increment("product_cache_hit");
return decoded.value;
}
} catch (error) {
metrics.increment("product_cache_error");
logger.warn({ error: summarize(error) }, "product cache read failed");
}
metrics.increment("product_cache_miss");
const product = await productRepository.findViewById(productId);
if (product) {
void writeProductCacheBestEffort(key, product);
}
return product;
}
캐시 쓰기가 실패해도 DB 조회 결과를 반환할 수 있다. 캐시는 최적화 계층이므로 캐시 갱신 실패가 원본 읽기까지 실패시키지 않는 것이 일반적이다. 다만 Redis 장애 때 DB가 급격한 fallback 부하를 감당할 수 있어야 한다.
캐시 데이터는 TTL 전에도 사라질 수 있다
TTL 5분을 설정했다고 key가 최소 5분 동안 반드시 존재하는 것은 아니다.
SET cache:product:42 ... EX 300
Redis가 maxmemory를 넘으면 설정된 eviction policy에 따라 key를 제거할 수 있다. 공식 Redis 문서에는 allkeys-lru, allkeys-lfu, volatile-*, noeviction 등 여러 정책이 있다.
| 정책 계열 | 메모리 한도 도달 시 개념적 행동 |
|---|---|
allkeys-lru |
모든 key 중 오래 사용되지 않은 후보 제거 |
allkeys-lfu |
모든 key 중 사용 빈도가 낮은 후보 제거 |
volatile-* |
TTL이 있는 key 집합에서 후보 제거 |
noeviction |
기존 key를 제거하지 않고 새 write에 오류 반환 |
정확한 근사 알고리즘과 지원 정책은 Redis Key Eviction 공식 문서를 확인한다.
eviction 외에도 key가 없어질 이유는 많다.
- TTL 만료
- 메모리 정책에 의한 eviction
- 운영자의 flush 또는 잘못된 delete
- failover 과정의 데이터 차이
- persistence 설정에 따른 재시작 후 손실
- cluster migration과 장애
- key naming 변경
따라서 “캐시에 없으면 존재하지 않는 상품”이라고 판단하면 안 된다.
// 잘못된 예
const raw = await redis.get(key);
if (!raw) {
throw new ProductNotFoundError();
}
miss는 원본 조회로 이어져야 한다. 존재하지 않음 자체를 명시적으로 캐시했다면 marker를 구분한다.
Redis persistence와 복제가 원본을 대신하지는 않는다
Redis는 persistence를 제공한다. RDB는 시점 snapshot, AOF는 write operation log 방식이며 둘을 조합하거나 persistence를 끌 수 있다. 하지만 설정에 따라 허용되는 데이터 손실과 latency trade-off가 다르다.
No persistence
→ 재시작 후 cache를 비워도 되는 경우
RDB snapshot
→ snapshot 사이 변경 손실 가능
AOF
→ fsync 정책에 따라 손실 범위와 쓰기 비용 변화
RDB + AOF
→ 복구 옵션 강화, 운영 비용 증가
공식 Redis Persistence 문서도 RDB snapshot만으로는 데이터 손실 최소화가 필요한 use case에 충분하지 않을 수 있음을 설명한다.
replica가 있다고 write가 즉시 영구 보존되는 것도 아니다. 비동기 복제라면 primary 장애 시 replica가 마지막 write를 받지 못했을 수 있다. failover는 가용성을 높이지만 애플리케이션 수준의 source of truth와 동일한 개념은 아니다.
캐시 전용 Redis라면 persistence를 끄고 재구축을 선택할 수도 있다. 이때 재시작 직후 DB 부하와 warm-up을 설계해야 한다. 반대로 세션·queue·rate limit·cache를 같은 Redis에 섞으면 key별 durability와 eviction 요구가 충돌한다.
cache는 eviction을 허용하고 싶지만 job queue는 임의 삭제를 허용할 수 없다. 메모리 정책과 장애 범위를 분리할 수 있도록 instance 또는 logical 운영 경계를 검토한다.
TTL은 정합성 모델의 일부다
TTL은 단순한 메모리 정리 시간이 아니다. DB가 바뀌었는데 캐시 무효화가 실패했을 때 사용자가 오래된 값을 볼 수 있는 최대 기간의 안전망이 된다.
DB update at 10:00
cache invalidation failed
cache TTL expires at 10:05
→ 최대 약 5분 stale 가능
TTL은 데이터별 허용 stale 시간으로 정한다.
| 데이터 | 예시 TTL | 판단 이유 |
|---|---|---|
| 공개 상품 설명 | 수 분 | 짧은 지연 허용 가능 |
| 추천 목록 | 수십 분 | 계산 결과의 근사성 허용 |
| 사용자 권한 | 매우 짧거나 캐시 재검토 | 권한 회수 지연 위험 |
| 재고·결제 상태 | 캐시 사용 범위 제한 | 오래된 값의 업무 비용 큼 |
| feature config | 버전·push invalidation 병행 | rollout 속도 요구 |
모든 key에 같은 5분을 복사하지 않는다. TTL이 너무 짧으면 hit ratio가 낮고 DB 부하가 늘며, 너무 길면 stale window가 커진다.
동시에 많은 key가 같은 시각에 만료되는 것도 피한다. 기본 TTL에 작은 jitter를 더한다.
function ttlWithJitter(baseSeconds: number): number {
const jitter = Math.floor(Math.random() * Math.max(1, baseSeconds * 0.1));
return baseSeconds + jitter;
}
존재하지 않음도 짧게 캐시한다
존재하지 않는 ID가 반복 요청되면 매번 DB miss가 발생한다. 공격이나 깨진 링크로 특정 ID가 집중되면 cache penetration이 된다.
GET product:999999
Redis miss
DB not found
반복...
not-found marker를 짧게 저장할 수 있다.
const NOT_FOUND = JSON.stringify({
schemaVersion: 1,
kind: "NOT_FOUND",
});
async function cacheNotFound(key: string): Promise<void> {
await redis.set(key, NOT_FOUND, { EX: 30 });
}
읽을 때 일반 값과 구분한다.
type CacheEnvelope<T> =
| { schemaVersion: 1; kind: "VALUE"; value: T }
| { schemaVersion: 1; kind: "NOT_FOUND" };
negative TTL은 일반 값보다 짧게 둔다. 존재하지 않던 상품이 바로 생성될 수 있기 때문이다. 생성 성공 후 같은 key의 negative marker를 삭제해야 한다.
사용자별 권한 결과를 전역 key에 캐시하면 다른 사용자의 접근 판단이 섞일 수 있다. cache key에 권한 경계를 포함하거나 public data만 캐시한다.
직렬화 포맷에도 버전이 필요하다
배포 전 캐시 형식:
{
"id": 42,
"price": 9900
}
배포 후 애플리케이션이 다음 형식을 기대한다고 하자.
{
"schemaVersion": 2,
"id": 42,
"price": {
"amount": 9900,
"currency": "KRW"
}
}
TTL이 남아 있으면 신·구 애플리케이션이 오래된 값을 읽는다. decode 시 스키마를 검증하고 알 수 없는 버전은 miss로 처리한다.
function decodeCachedProduct(raw: string): CachedProduct | null {
try {
const parsed: unknown = JSON.parse(raw);
if (!isCachedProductV3(parsed)) {
metrics.increment("product_cache_decode_miss");
return null;
}
return parsed;
} catch {
metrics.increment("product_cache_corrupt");
return null;
}
}
key namespace에 cache schema version을 넣는 방식도 단순하다.
cache:catalog:product:v3:42
새 버전 배포 시 v4 key를 읽고 miss부터 다시 채운다. 기존 key는 TTL로 자연스럽게 사라진다. 대신 버전 전환 직후 hit ratio가 내려가므로 gradual rollout과 warm-up을 계획한다.
캐시 키는 데이터 계약이다
좋은 key에는 충돌을 피할 namespace와 데이터 정합성 경계가 드러난다.
cache:{service}:{resource}:{schemaVersion}:{scope}:{id}
예시:
cache:catalog:product:v3:tenant-42:product-501
다음 요소를 검토한다.
- service와 environment가 다른 key를 덮어쓰지 않는가?
- tenant/user 권한 범위가 key에 반영되는가?
- locale, currency, feature flag에 따라 결과가 다른가?
- serialization schema version이 구분되는가?
- key 길이와 cardinality가 과도하지 않은가?
- invalidation할 때 정확한 key를 계산할 수 있는가?
locale을 빼먹으면 한국어 응답을 영어 사용자에게 줄 수 있다.
잘못된 key: product:42
필요한 key: product:v3:42:locale=ko-KR
모든 요청 query parameter를 그대로 key에 넣으면 cardinality가 폭증하고 개인정보가 노출될 수 있다. 허용된 정규화 필드만 안정적인 순서로 구성하거나 hash를 사용한다.
Redis 장애 시 DB를 어떻게 보호할까
Redis가 다운되면 Cache Aside는 모두 miss처럼 DB로 fallback한다. 정상 시 DB가 요청의 5%만 처리했다면 갑자기 100%를 받아야 한다.
정상
Redis hit 95%
DB read 5%
Redis outage
Redis hit 0%
DB read 100% ← 20배
DB가 이를 버티지 못하면 cache 장애가 전체 장애로 확대된다. 다음 보호 장치를 조합한다.
짧은 Redis timeout
캐시는 지연을 줄이기 위한 계층이다. 매 요청이 Redis 연결 timeout 2초를 기다리면 없는 것보다 나쁘다.
const redis = createRedisClient({
connectTimeoutMs: 100,
commandTimeoutMs: 50,
});
숫자는 네트워크와 SLO에 맞춰 정한다.
Circuit Breaker
오류율이 높을 때 일정 시간 Redis 호출을 생략해 timeout과 커넥션 폭증을 막는다.
CLOSED → 오류 임계치 초과 → OPEN
OPEN → Redis 호출 생략, 제한된 DB fallback
OPEN → cooldown 후 HALF_OPEN probe
DB fallback rate limit
DB가 감당할 수 있는 동시 조회만 허용하고 나머지는 빠르게 실패하거나 stale local cache를 반환한다.
짧은 in-process stale cache
정합성 요구가 허용한다면 마지막 정상 값을 프로세스 메모리에 짧게 보관해 Redis 장애 동안 사용한다. 인스턴스마다 값이 다를 수 있음을 명시한다.
중요도별 degradation
상품 상세은 DB fallback, 추천은 빈 목록, 부가 통계는 생략하는 식으로 기능별 우선순위를 둔다.
Redis를 건너뛰는 코드가 DB를 보호하는 동시성 제한 없이 실행되면 장애를 증폭한다. 부하 테스트에서 Redis를 차단하고 DB·커넥션 풀이 유지되는지 확인한다.
Cache Stampede와 동시 miss
인기 key가 만료되는 순간 1,000개 요청이 동시에 miss를 보면 모두 DB를 조회할 수 있다.
sequenceDiagram
participant A as Request A
participant B as Request B
participant R as Redis
participant D as Database
A->>R: GET hot-key
B->>R: GET hot-key
R-->>A: miss
R-->>B: miss
A->>D: SELECT
B->>D: SELECT
Note over D: 같은 쿼리 대량 실행한 프로세스 안에서는 promise single-flight로 중복 조회를 합칠 수 있다.
const inFlight = new Map<string, Promise<ProductView | null>>();
async function loadSingleFlight(
key: string,
loader: () => Promise<ProductView | null>,
) {
const existing = inFlight.get(key);
if (existing) return existing;
const promise = loader().finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
여러 인스턴스 사이에는 짧은 distributed lock, stale-while-revalidate, probabilistic early refresh 등을 검토한다. lock을 얻지 못한 요청을 무한 대기시키지 않고 stale 값을 주거나 짧게 retry한다.
soft TTL: 이 시점부터 한 요청이 background refresh
hard TTL: 이 시점 이후에는 값을 사용하지 않음
이 문제는 79편 Cache Aside 패턴과 캐시 무효화에서 쓰기와 invalidation race까지 이어서 다룬다.
Cold Start와 캐시 워밍
Redis 재시작이나 schema version 변경 후 cache hit ratio가 0에 가까워진다. 모든 key를 한 번에 채우면 DB와 네트워크에 큰 부하를 만든다.
cache cold start
→ DB QPS 급증
→ latency 증가
→ timeout과 retry
→ 추가 DB 부하
워밍 전략:
- 상위 인기 key만 사전 적재
- 낮은 동시성으로 batch warm-up
- 실제 요청에 따른 lazy population
- rollout 그룹별 cache version 전환
- stale snapshot을 임시 seed로 사용
for (const batch of chunk(popularProductIds, 100)) {
await warmProducts(batch, { concurrency: 10 });
await delay(100);
}
전체 카탈로그를 무조건 워밍하면 한 번도 읽히지 않을 key까지 메모리를 차지한다. 실제 access frequency와 eviction policy를 기준으로 hot set을 정한다.
캐시하면 안 되는 데이터
캐시 사용은 stale 결과가 업무상 허용되는가로 판단한다.
권한과 계정 차단
권한 회수가 TTL 동안 늦어지면 보안 문제가 된다. push invalidation과 매우 짧은 TTL을 써도 실패 시 안전한 기본값이 무엇인지 확인한다.
결제 확정 상태
오래된 PENDING을 보고 결제를 다시 시도하거나 오래된 PAID를 믿고 상품을 제공하면 문제가 된다. 최종 결정은 원본 DB 또는 결제 provider 검증을 사용한다.
유한 재고
화면 표시용 근사 재고는 캐시할 수 있어도 주문 성공 판정은 원자적 DB 변경 같은 authoritative operation이 담당해야 한다.
UPDATE products
SET stock = stock - :quantity
WHERE id = :id
AND stock >= :quantity;
개인정보와 민감 데이터
Redis 접근 권한, encryption, memory dump, 로그와 key 이름에 개인정보가 노출되지 않는지 확인한다. 캐시에 저장하지 않는 것이 가장 안전한 데이터도 있다.
운영 지표와 장애 테스트
캐시는 hit ratio 하나만 보면 부족하다.
| 지표 | 의미 | 이상 신호 |
|---|---|---|
| hit/miss ratio | 실제 적중률 | 배포 후 급락 |
| cache latency p95/p99 | 캐시 자체 지연 | DB보다 느려짐 |
| error/timeout rate | 연결·명령 실패 | fallback 증가 |
| evicted_keys | 메모리 압력 | TTL 전 key 손실 증가 |
| expired_keys | TTL에 의한 만료 | 동시 만료 spike |
| memory usage | hot set 적합성 | maxmemory 근접 |
| DB fallback QPS | cache가 줄인 부하 | 장애 때 안전 한도 초과 |
| stampede suppression | 중복 loader 합침 | hot key DB fan-out |
| decode failures | schema 불일치·손상 | 배포 호환 문제 |
장애 실험 시나리오:
- Redis 연결을 완전히 차단한다.
- 응답이 짧은 timeout 뒤 fallback 또는 degradation되는지 본다.
- DB QPS와 connection pool이 안전 한도에 머무는지 확인한다.
- Redis를 빈 상태로 복구해 cold start를 재현한다.
- eviction을 유도해 miss 분포를 확인한다.
- 잘못된 serialization version을 넣어 decoder가 miss로 처리하는지 본다.
- 인기 key를 동시에 만료시켜 stampede 방지 효과를 측정한다.
it("알 수 없는 cache schema는 DB miss로 복구한다", async () => {
await redis.set(productCacheKey(42), JSON.stringify({
schemaVersion: 999,
value: {},
}));
const product = await productService.getProduct(42);
expect(product?.id).toBe(42);
expect(productRepository.findViewById).toHaveBeenCalledWith(42);
});
결론
Redis를 캐시로 사용할 때 가장 중요한 계약은 cache key가 언제든 사라져도 authoritative source에서 다시 만들 수 있다는 것이다. TTL이 남아 있어도 eviction, 재시작과 장애로 key는 없어질 수 있고, persistence와 replica도 설정에 따른 durability trade-off를 가진다. Redis 값만으로 재고·결제·권한의 최종 상태를 결정하면 캐시가 원본으로 변한 것이다.
Cache Aside에서는 miss와 cache error를 DB fallback으로 연결하되, Redis 전체 장애가 DB QPS를 폭증시키지 않도록 timeout, circuit breaker, 동시성 제한과 degradation을 설계해야 한다. negative cache, TTL jitter, single-flight로 penetration과 stampede를 줄이고 cache schema version과 key namespace로 배포 호환성을 지킨다. 캐시 성능은 hit ratio뿐 아니라 eviction, fallback QPS, decode failure와 cold-start 실험으로 검증해야 한다.
관련 노트
- Cache Aside 패턴과 캐시 무효화
- Outbox Pattern으로 DB와 이벤트 발행 일치시키기
- 재시도 가능한 API에 Idempotency-Key 적용하기
- 트랜잭션의 ACID를 실제 주문 처리로 이해하기
- 운영 지표에서 평균보다 백분위수를 보는 이유
- Redis